<template>
  <div style="display: flex; height: 100vh">
    <div
      style="
        flex: 1;
        display: flex;
        flex-direction: column;
        padding: 10px;
        box-sizing: border-box;
        overflow: auto;
      "
    >
      <div class="wrap">
        <div class="panel">
          <h3>场景</h3>
          <el-select
            v-model="scenario"
            style="width: 220px; margin-bottom: 8px"
          >
            <el-option
              v-for="s in scenarios"
              :key="s.value"
              :label="s.label"
              :value="s.value"
            />
          </el-select>
          <el-button type="primary" size="small" @click="gen()"
            >生成用例</el-button
          >
          <el-button size="small" @click="shuffle()">随机</el-button>
          <div class="seq">
            <div><b>旧序列</b></div>
            <div class="chips">
              <span v-for="k in oldKeys" :key="'o-' + k" class="chip">{{
                k
              }}</span>
            </div>
            <div><b>新序列</b></div>
            <div class="chips">
              <span v-for="k in newKeys" :key="'n-' + k" class="chip green">{{
                k
              }}</span>
            </div>
          </div>
        </div>

        <div class="panel">
          <h3>Vue2 策略（双端 diff）</h3>
          <div class="stats">
            <div>
              操作数：<b>{{ opsVue2.length }}</b>
            </div>
            <div>明细：</div>
          </div>
          <pre class="log">{{ opsVue2.map(fmtOp).join("\n") }}</pre>
        </div>

        <div class="panel">
          <h3>Vue3 策略（trim + 中段映射 + LIS 最少移动）</h3>
          <div class="stats">
            <div>
              操作数：<b>{{ opsVue3.length }}</b>
            </div>
            <div>明细：</div>
          </div>
          <pre class="log">{{ opsVue3.map(fmtOp).join("\n") }}</pre>
        </div>
      </div>

      <div class="play">
        <div class="stage">
          <h4>动画演示（应用 Vue2 操作）</h4>
          <ul ref="ul2" class="ul">
            <li v-for="k in oldKeys" :key="'v2-' + k" class="li" :data-k="k">
              {{ k }}
            </li>
          </ul>
          <el-button type="warning" size="small" @click="runOps('v2')"
            >播放</el-button
          >
        </div>
        <div class="stage">
          <h4>动画演示（应用 Vue3 操作）</h4>
          <ul ref="ul3" class="ul">
            <li v-for="k in oldKeys" :key="'v3-' + k" class="li" :data-k="k">
              {{ k }}
            </li>
          </ul>
          <el-button type="success" size="small" @click="runOps('v3')"
            >播放</el-button
          >
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
// 你项目里已用 Element Plus / <script setup>，沿用即可
import { ref, watch, nextTick } from "vue";

// ======= 数据与场景 =======
const scenarios = [
  { value: "head-insert", label: "头部插入（在前面加若干项）" }, // trim 头部对不上，考验前插
  { value: "tail-insert", label: "尾部插入（在后面加若干项）" }, // trim 尾部对不上，但代价小
  { value: "middle-insert", label: "中段少量插入（LIS 友好）" }, // ★ 只在中间插入少量 → LIS 爆发
  { value: "swap-adjacent-pairs", label: "相邻小对换（LIS 友好）" }, // ★ A B C D → B A D C：长LIS存在，移动很少
  { value: "block-move", label: "整块搬移（LIS 友好）" }, // ★ 把一段连续块整体移位 → LIS 很长
  { value: "rotation-k", label: "整体旋转k位（头尾错位+长LIS）" }, // ★ 循环右移k：LIS≈n-k
  { value: "sparse-delete-insert", label: "稀疏删+插（保序比例高，LIS 友好）" }, // ★ 10%删+10%插，顺序基本保留
  { value: "reverse", label: "完全反转（LIS 不友好）" }, // 极端，大家都难
  { value: "random-weak", label: "轻度乱序（打散但保序比例高）" }, // ★ 比 random 更温和，LIS 仍较长
  { value: "random-strong", label: "强随机乱序（同集合）" }, // 接近最坏
];
const scenario = ref("middle-insert");
const base = Array.from(
  { length: 40 },
  (_, i) => String.fromCharCode(65 + (i % 26)) + Math.floor(i / 26)
);
// 例：['A0','B0',...,'N1'] 更直观避免重复字母
const oldKeys = ref([...base]);
const newKeys = ref([]);

function gen() {
  const old = [...base];
  const N = old.length;
  let nu = [];

  switch (scenario.value) {
    case "head-insert": {
      nu = ["X1", "X2", "X3", ...old]; // 头插：考trim+插入
      break;
    }
    case "tail-insert": {
      nu = [...old, "X1", "X2", "X3"]; // 尾插：最轻松
      break;
    }
    case "middle-insert": {
      nu = [...old];
      // 在中段插入少量新key（不破坏旧序） → LIS 很长，只需少量 insert
      const mid = Math.floor(N / 2);
      nu.splice(mid, 0, "X1", "X2", "X3", "X4", "X5");
      break;
    }
    case "swap-adjacent-pairs": {
      // A B C D E F → B A D C F E：大量元素相对顺序保持（跨pair间不动），LIS 很长
      nu = [];
      for (let i = 0; i < N; i += 2) {
        if (i + 1 < N) nu.push(old[i + 1], old[i]);
        else nu.push(old[i]);
      }
      break;
    }
    case "block-move": {
      // 把中间一大块（连续）整体搬到后面，其他相对顺序不变 → LIS 保留两大块
      const L = Math.floor(N * 0.3); // block 长度
      const s = Math.floor(N * 0.35); // block 起点
      const block = old.slice(s, s + L);
      nu = [...old.slice(0, s), ...old.slice(s + L), ...block];
      break;
    }
    case "rotation-k": {
      const k = Math.floor(N * 0.25); // 右旋 k
      nu = [...old.slice(N - k), ...old.slice(0, N - k)];
      break;
    }
    case "sparse-delete-insert": {
      // 随机删除 10% 再随机插入 10% 新key，原有顺序尽量不变
      const delIdx = new Set();
      while (delIdx.size < Math.floor(N * 0.1))
        delIdx.add((Math.random() * N) | 0);
      const kept = old.filter((_, i) => !delIdx.has(i));
      // 在随机位置插入新key若干，但不改 kept 的内部顺序 → LIS 长
      nu = [...kept];
      const insCount = Math.floor(N * 0.1);
      for (let i = 0; i < insCount; i++) {
        const pos = (Math.random() * (nu.length + 1)) | 0;
        nu.splice(pos, 0, "X" + i);
      }
      break;
    }
    case "reverse": {
      nu = [...old].reverse(); // 最差序列（LIS≈1）
      break;
    }
    case "random-weak": {
      // 轻度乱序：做若干次相邻 swap（保序程度高）
      nu = [...old];
      for (let t = 0; t < Math.floor(N * 0.15); t++) {
        const i = (Math.random() * (N - 1)) | 0;
        [nu[i], nu[i + 1]] = [nu[i + 1], nu[i]];
      }
      break;
    }
    case "random-strong":
    default: {
      nu = shuffleArray(old);
    }
  }
  oldKeys.value = old;
  newKeys.value = nu;
}

function shuffle() {
  scenario.value = "random-strong";
  gen();
}
gen();

function shuffleArray(arr) {
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = (Math.random() * (i + 1)) | 0;
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

// ======= 操作数据结构 =======
// op = { type: 'insert'|'remove'|'move', key, from?:number, to?:number, after?:key|null }
function fmtOp(op) {
  if (op.type === "insert") return `插入 ${op.key} 到索引 ${op.to}`;
  if (op.type === "remove") return `删除 ${op.key}（原索引 ${op.from}）`;
  if (op.type === "move") return `移动 ${op.key}：${op.from} → ${op.to}`;
  return JSON.stringify(op);
}

// ======= Vue2：双端 diff（简化版，含 keyMap）=======
// 思想：同时比较新旧首/尾四种命中；否则用 keyMap 定位并移动
/**
 * 简化还原 Vue2 的双端指针 diff 流程：
 * - 四端命中优先：
 *   1) oldStart === newStart  → 指针前进
 *   2) oldEnd   === newEnd    → 指针后退
 *   3) oldStart === newEnd    → 把旧头挪到旧尾之后（末端）→ oldStart++，newEnd--
 *   4) oldEnd   === newStart  → 把旧尾挪到旧头之前（开端）→ oldEnd--，newStart++
 * - 若四端都不命中：
 *   - 惰性构建 key→index 映射，在 old 范围内查找 newStart
 *   - 找不到：在 oldStart 位置 “插入” 新节点（相当于创建）
 *   - 找得到：把该旧节点 “移动” 到 oldStart 位置
 * - 循环结束后，清尾：
 *   - new 还有剩：批量在 oldStart 往前插入
 *   - old 还有剩：批量从 oldStart..oldEnd 删除
 *
 * 注意：
 * - 为了让 from/to 总是指向“当前状态”，我们在 work（old 的副本）上真执行 splice，
 *   并在每次 splice 后维护 key→index 映射。
 */
function diffVue2(oldArr, newArr) {
  const ops = [];

  // 工作副本：模拟“当前真实 DOM 子序列”的内容
  const work = oldArr.slice();

  // 维护：key -> 当前 index
  const indexMap = new Map();
  for (let i = 0; i < work.length; i++) indexMap.set(work[i], i);

  // 四端指针
  let oldStart = 0;
  let oldEnd = work.length - 1;
  let newStart = 0;
  let newEnd = newArr.length - 1;

  // 惰性构建的 “当前有效窗口” 内 key→index 映射
  let keyToOldIndex = null;
  const ensureKeyMap = () => {
    if (keyToOldIndex) return;
    keyToOldIndex = new Map();
    for (let i = oldStart; i <= oldEnd; i++) {
      keyToOldIndex.set(work[i], i);
    }
  };

  // 一个小工具：每次 splice 后，把 start 及右侧的 index 重新写回 indexMap
  const refreshIndexFrom = (start) => {
    for (let i = start; i < work.length; i++) indexMap.set(work[i], i);
  };

  // 循环对比
  while (oldStart <= oldEnd && newStart <= newEnd) {
    const os = work[oldStart]; // 旧头
    const oe = work[oldEnd]; // 旧尾
    const ns = newArr[newStart]; // 新头
    const ne = newArr[newEnd]; // 新尾

    if (os === ns) {
      // 旧头 = 新头：就地复用，头指针推进
      oldStart++;
      newStart++;
      keyToOldIndex = null; // 窗口变了，映射下次再构
      continue;
    }
    if (oe === ne) {
      // 旧尾 = 新尾：就地复用，尾指针回退
      oldEnd--;
      newEnd--;
      keyToOldIndex = null;
      continue;
    }
    if (os === ne) {
      // 旧头 = 新尾：把旧头挪到旧尾之后（窗口末端）
      // DOM 等价：insertBefore(旧头DOM, 旧尾DOM.nextSibling)
      const from = oldStart;
      const to = oldEnd + 1; // 末端之后的“插入位”
      ops.push({ type: "move", key: os, from, to });

      // 实际移动
      work.splice(to, 0, work.splice(from, 1)[0]);
      refreshIndexFrom(Math.min(from, to));

      // 窗口收缩：此轮已经“确定”了一个末端位
      oldEnd--;
      newEnd--;
      keyToOldIndex = null;
      continue;
    }
    if (oe === ns) {
      // 旧尾 = 新头：把旧尾挪到旧头之前（窗口开端）
      // DOM 等价：insertBefore(旧尾DOM, 旧头DOM)
      const from = oldEnd;
      const to = oldStart;
      ops.push({ type: "move", key: oe, from, to });

      work.splice(to, 0, work.splice(from, 1)[0]);
      refreshIndexFrom(Math.min(from, to));

      // 窗口收缩：此轮已经“确定”了一个开端位
      oldStart++;
      newStart++;
      keyToOldIndex = null;
      continue;
    }

    // 四端都没命中：查表（仅在当前窗口范围内）
    ensureKeyMap();
    const idxInOld = keyToOldIndex.get(ns);

    if (idxInOld == null || idxInOld < oldStart || idxInOld > oldEnd) {
      // 当前新头在旧窗口中不存在 → 需要“在 oldStart 位置创建并插入”
      ops.push({ type: "insert", key: ns, to: oldStart });

      work.splice(oldStart, 0, ns);
      refreshIndexFrom(oldStart);

      // 移动窗口（oldStart 向右，新头向右；旧窗口右边界也右移一格，因为长度+1）
      oldStart++;
      newStart++;
      oldEnd++;
      keyToOldIndex = null; // 窗口改变，映射作废
    } else {
      // 找到了：把旧里的该节点“挪到 oldStart”位置（复用）
      const from = idxInOld;
      const to = oldStart;
      ops.push({ type: "move", key: ns, from, to });

      work.splice(to, 0, work.splice(from, 1)[0]);
      refreshIndexFrom(Math.min(from, to));

      // 指针推进
      oldStart++;
      newStart++;
      keyToOldIndex = null;
    }
  }

  // 收尾阶段：
  if (newStart <= newEnd) {
    // new 还有剩：批量在 oldStart 开始插入
    for (let i = newStart; i <= newEnd; i++) {
      const key = newArr[i];
      ops.push({ type: "insert", key, to: oldStart + (i - newStart) });
      work.splice(oldStart + (i - newStart), 0, key);
      refreshIndexFrom(oldStart + (i - newStart));
    }
  } else if (oldStart <= oldEnd) {
    // old 还有剩：批量删除
    for (let i = oldStart; i <= oldEnd; i++) {
      const key = work[i];
      ops.push({ type: "remove", key, from: i });
      work.splice(i, 1);
      refreshIndexFrom(i);
      oldEnd--;
      i--; // 删除后回退一位
    }
  }

  return ops;
}
// ======= Vue3：trim + 中段映射 + LIS（简化版）=======
// 步骤：头尾相等部分先对齐；中段建立 newIndexForOldIndex 映射；用 LIS 计算最少移动
/**
 * 计算 LIS（最长递增子序列）的“索引序列”（返回保留的下标集合）
 * - 输入: 数组 arr（包含非负整数；0 表示“新中不存在”时会被忽略）
 * - 输出: 组成 LIS 的“位置下标”数组（这些位置的元素保持不动，其他需要移动）
 */
function getLISIndexes(arr) {
  const n = arr.length;
  const pre = new Array(n).fill(-1); // 记录每个位置在 LIS 链中的前驱
  const tails = []; // tails[k] = 形成长度为 k+1 的 LIS 的“末尾下标”
  const tailsIdx = []; // 对应保存的下标（便于回溯）

  for (let i = 0; i < n; i++) {
    const v = arr[i];
    if (v === 0) continue; // 0 表示该位是“新增”，不参与 LIS
    // 二分查找 v 在 tails 对应的插入位置（按 arr[tails[k]] 的值递增）
    let l = 0,
      r = tails.length;
    while (l < r) {
      const m = (l + r) >> 1;
      if (arr[tails[m]] < v) l = m + 1;
      else r = m;
    }
    // l 即插入位置
    if (l > 0) pre[i] = tails[l - 1]; // 记录前驱：本 i 的前一个来自 tails[l-1]
    if (l === tails.length) tails.push(i);
    else tails[l] = i;
  }

  // 回溯得到 LIS 的“索引序列”
  const lis = [];
  for (let i = tails[tails.length - 1]; i >= 0; i = pre[i]) {
    lis.push(i);
  }
  lis.reverse();
  return lis; // 这些下标的元素（对应 newIndexToOldIndexMap 的位置）可不动
}

/**
 * 用“工作数组”模拟把 oldArr 变成 newArr，返回可执行 ops
 * - 步骤：
 *   1) 删掉 old 中不在 new 的元素
 *   2) 前后 trim（跳过两端相同片段）
 *   3) 中段建立 newIndexToOldIndexMap（长度 = 需要对齐的中段长度）
 *   4) 计算 LIS：保持 LIS 中的元素不动，其余按新顺序从后往前插/移
 * - 这是“简化版”的 Vue3 思路：结果正确，移动次数较少
 */
function diffVue3(oldArr, newArr) {
  const ops = [];
  const work = oldArr.slice(); // “当前实际状态”的工作副本
  const indexMap = new Map(work.map((k, i) => [k, i]));
  const newSet = new Set(newArr);

  // ---------- (1) 先移除 old 中不存在于 new 的元素 ----------
  for (let i = 0; i < work.length; ) {
    const key = work[i];
    if (!newSet.has(key)) {
      ops.push({ type: "remove", key, from: i });
      work.splice(i, 1);
      indexMap.delete(key);
      // 更新被影响的索引
      for (let j = i; j < work.length; j++) indexMap.set(work[j], j);
    } else {
      i++;
    }
  }

  // ---------- (2) 两端 trim：跳过相同前缀 ----------
  let oldStart = 0,
    oldEnd = work.length - 1;
  let newStart = 0,
    newEnd = newArr.length - 1;

  while (
    oldStart <= oldEnd &&
    newStart <= newEnd &&
    work[oldStart] === newArr[newStart]
  ) {
    oldStart++;
    newStart++;
  }
  // 跳过相同后缀
  while (
    oldStart <= oldEnd &&
    newStart <= newEnd &&
    work[oldEnd] === newArr[newEnd]
  ) {
    oldEnd--;
    newEnd--;
  }

  // 若全部对齐完成，说明只做了删改，已完事（或只需在缺口插入）
  if (newStart > newEnd) {
    // new 中段为空，如 work 中段还剩元素，理论上前面已经删过；这里无事可做
    return ops;
  }

  // ---------- (3) 中段映射：构建 newKey->newIndex ----------
  const keyToNewIndex = new Map();
  for (let i = newStart; i <= newEnd; i++) keyToNewIndex.set(newArr[i], i);

  // ---------- (4) 构建 newIndexToOldIndexMap & 处理多余旧元素 ----------
  const toBePatched = newEnd - newStart + 1;
  const newIndexToOldIndexMap = new Array(toBePatched).fill(0); // 0 表示该新位在 old 中不存在（需要 insert）

  for (let i = oldStart; i <= oldEnd; i++) {
    const oldKey = work[i];
    const mappedNewIndex = keyToNewIndex.get(oldKey);
    if (mappedNewIndex === undefined) {
      // 旧中段里有 new 中段不存在的，删除
      ops.push({ type: "remove", key: oldKey, from: i });
      work.splice(i, 1);
      indexMap.delete(oldKey);
      for (let j = i; j < work.length; j++) indexMap.set(work[j], j);
      oldEnd--;
      i--; // 回退 i，继续扫描下一个
    } else {
      // 记录 old 索引（+1 避免与 0 混淆）
      newIndexToOldIndexMap[mappedNewIndex - newStart] = i + 1;
    }
  }

  // ---------- (5) 计算 LIS：这些位置对应元素可不动 ----------
  const lisPositions = new Set(getLISIndexes(newIndexToOldIndexMap));

  // ---------- (6) 逆序遍历中段：按新顺序从后向前处理 insert/move ----------
  for (let i = toBePatched - 1; i >= 0; i--) {
    const newIndex = i + newStart;
    const newKey = newArr[newIndex];
    const mapped = newIndexToOldIndexMap[i];

    // 目标插入位置 = newIndex（在 work 中的绝对位置）
    // 但 work 可能尚未完全对齐：我们以“把 newIndex 处应该是 newKey”为目标执行
    if (mapped === 0) {
      // 该新位在 old 不存在 -> 需要插入
      ops.push({ type: "insert", key: newKey, to: newIndex });
      work.splice(newIndex, 0, newKey);
      // 更新索引映射
      for (let j = newIndex; j < work.length; j++) indexMap.set(work[j], j);
    } else {
      // 该新位在 old 存在
      const oldPos = mapped - 1; // 还原 old 索引
      // 如果该位置属于 LIS，且当前 newKey 已经在 newIndex 上，则无需移动
      // 否则需要把它移到 newIndex
      const alreadyAt = indexMap.get(newKey) === newIndex;
      const isInLIS = lisPositions.has(i);

      if (!alreadyAt) {
        if (isInLIS) {
          // 在 LIS 中理论上应已就位；若没就位，说明前面插入/删除影响了位置，仍需对齐
        }
        const curPos = indexMap.get(newKey);
        // 执行 move：从 curPos -> newIndex
        ops.push({ type: "move", key: newKey, from: curPos, to: newIndex });
        // 工作数组上实际移动
        work.splice(newIndex, 0, work.splice(curPos, 1)[0]);
        // 维护映射（受影响区间的重新赋值）
        const start = Math.min(curPos, newIndex);
        for (let j = start; j < work.length; j++) indexMap.set(work[j], j);
      }
    }
  }

  return ops;
}

// 最长递增子序列（简化版，返回序列值，不返回索引链；用于理解/统计）
function lis(arr) {
  const d = [];
  for (const x of arr) {
    if (d.length === 0 || x > d[d.length - 1]) d.push(x);
    else {
      // 二分替换
      let l = 0,
        r = d.length - 1;
      while (l < r) {
        const m = (l + r) >> 1;
        if (d[m] < x) l = m + 1;
        else r = m;
      }
      d[l] = x;
    }
  }
  return d;
}

// ======= 计算与展示 =======
const opsVue2 = ref([]);
const opsVue3 = ref([]);

watch(
  [oldKeys, newKeys],
  () => {
    // 传入副本，避免算法内修改影响源数组
    opsVue2.value = diffVue2([...oldKeys.value], [...newKeys.value]);
    opsVue3.value = diffVue3([...oldKeys.value], [...newKeys.value]);
  },
  { immediate: true }
);

// ======= 动画演示，把 ops 应用到真实 DOM =======
const ul2 = ref(null);
const ul3 = ref(null);

async function runOps(which) {
  const ul = which === "v2" ? ul2.value : ul3.value;
  const ops = which === "v2" ? opsVue2.value : opsVue3.value;

  // 重置舞台为 oldKeys
  ul.innerHTML = "";
  for (const k of oldKeys.value) {
    const li = document.createElement("li");
    li.className = "li";
    li.dataset.k = k;
    li.textContent = k;
    ul.appendChild(li);
  }
  await nextTick();
  // 逐步应用
  for (const op of ops) {
    await applyOp(ul, op);
  }
}

function qsByKey(ul, key) {
  return ul.querySelector(`[data-k="${key}"]`);
}
function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}
async function applyOp(ul, op) {
  if (op.type === "insert") {
    const li = document.createElement("li");
    li.className = "li new";
    li.dataset.k = op.key;
    li.textContent = op.key;
    const refNode = ul.children[op.to] || null;
    ul.insertBefore(li, refNode);
    await sleep(300);
    li.classList.remove("new");
  } else if (op.type === "remove") {
    const li = qsByKey(ul, op.key);
    if (li) {
      li.classList.add("rm");
      await sleep(250);
      li.remove();
    }
  } else if (op.type === "move") {
    const li = qsByKey(ul, op.key);
    if (li) {
      li.classList.add("mv");
      await sleep(150);
      const refNode = ul.children[op.to] || null;
      ul.insertBefore(li, refNode);
      await sleep(150);
      li.classList.remove("mv");
    }
  }
}
</script>

<style scoped>
.wrap {
  display: grid;
  grid-template-columns: 320px 1fr 1fr;
  gap: 12px;
  padding: 12px;
}
.panel {
  /* background: #161616; */
  border: 1px solid #2a2a2a;
  border-radius: 10px;
  padding: 12px;
}
.seq {
  margin-top: 12px;
}
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 6px 0 12px;
}
.chip {
  padding: 3px 8px;
  border-radius: 999px;
  /* background: #2b2b2b; */
  border: 1px solid #3b3b3b;
}
.chip.green {
  /* background: #20351f; */
  border-color: #305a2e;
}
.stats {
  font-size: 13px;
  color: #bbb;
  margin-bottom: 4px;
}
.log {
  /* background: #0f0f0f; */
  border: 1px solid #242424;
  border-radius: 8px;
  padding: 8px;
  /* max-height: 240px; */
  overflow: auto;
}
.play {
  margin-top: 14px;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  padding: 12px;
  margin-bottom: 60px;
}
.stage {
  /* background: #161616; */
  border: 1px solid #2a2a2a;
  border-radius: 10px;
  padding: 10px;
}
.ul {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  list-style: none;
  padding: 8px 0;
  margin: 0;
  min-height: 46px;
}
.li {
  width: 40px;
  height: 40px;
  display: grid;
  place-items: center;
  /* background: #1f1f1f; */
  border: 1px solid #333;
  border-radius: 8px;
  transition: transform 0.18s ease, background 0.18s ease, opacity 0.18s ease;
}
.li.new {
  background: #204021;
}
.li.rm {
  opacity: 0.2;
  transform: scale(0.85);
}
.li.mv {
  background: #26223f;
  transform: scale(1.05);
}
</style>
